Skip to content

feature: add export command that exports mails to markdown#1

Merged
janhapke merged 18 commits into
mainfrom
feature/export
Jun 5, 2026
Merged

feature: add export command that exports mails to markdown#1
janhapke merged 18 commits into
mainfrom
feature/export

Conversation

@janhapke

@janhapke janhapke commented Jun 5, 2026

Copy link
Copy Markdown
Owner

backmail export — HTML/plain-text archive export to Markdown

Adds a new backmail export command that converts the entire .eml archive into human- and AI-readable Markdown files, one per message, mirroring the folder structure under export/.

What it does

  • Reads every message from the archive state files
  • Converts HTML bodies to Markdown via Turndown with GFM plugin
  • Converts plain-text bodies to fenced Markdown with correct hard line breaks and escaped Markdown hazards
  • Writes export/<folder>/<filename>.md with YAML frontmatter + subject heading + body

Output format

Each file looks like:

---
messageId: "<id>"
from: "Name <email>"
to:
  - "Recipient <email>"
subject: "…"
received: "2026-01-15 13:37:00"
folders:
  - "INBOX"
formats:
  - "plaintext"
  - "html"
attachments: []
---

Subject line here
=================

Plain text body here…

---
---
---

**[html-body below - automatically converted from HTML; may contain formatting loss or artifacts]**

---
---
---

HTML-converted body here…

Conversion quality improvements

A significant portion of this PR is hardening the conversion pipeline against the messy reality of HTML email:

Problem Fix
Layout tables rendered as broken GFM pipe tables Smart table detection: unwrap to prose unless first row is all <th> with inline-only content
<th> cells used for two-column newsletter grids cellHasBlockContent check — if a <th> contains <div>, <img>, <p> etc. it's layout, not a header
Layout table cells collapsing to a single run-on line addMarkdownHardBreaks pass adds \ between consecutive prose lines
Logos, tracking pixels, layout images in output Strip all <img> except cid: inline attachments
Preheader padding characters (&#8199;, &#847;, &shy;) surviving as invisible junk Strip in both preprocessHtml and as a post-Turndown pass (entities are decoded inside Turndown's DOM parser)
Empty [](url) links from stripped image anchors Post-processing regex removes them
Hard \ markers on quoted reply lines (>) Classify > lines as block elements — no hard-break markers
Tab-only separator lines triggering spurious \ Whitespace-only lines treated as blank in classifyLine
ATX headings and Setext underlines in plain text Escape #, normalise ---/=== lines to thematic breaks

Tests

  • 300 unit tests (295 pre-existing + 5 new for this branch's additions), 1 skipped
  • Integration tests in tests/integration/export.test.ts — full export of a temp archive across two folders, idempotency, --only-folder, --exclude-folder

janhapke and others added 18 commits June 4, 2026 23:04
- Add convertPlainText(): appends trailing \ between consecutive prose
  lines to preserve hard line breaks in CommonMark
- Normalise ASCII divider lines (---, ===) to thematic breaks; inserts
  blank line before divider so preceding text is not parsed as a Setext
  heading
- Escape leading # on ATX heading lines (\# text) so plain text content
  is not promoted to a heading
- HTML-escape < and > throughout
- Detect manually typed list lines (- / * / N.) as block elements that
  never receive trailing \

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Unwrap layout tables (no <th> / role="presentation") to prose; keep
  data tables as GFM pipe tables
- Strip invisible email spacer characters (soft hyphen, ZWS etc.) in
  preprocessHtml
- Post-process htmlToMarkdown output: replace Unicode non-breaking
  spaces, trim trailing whitespace per line, collapse 3+ newlines to 2,
  convert Turndown-escaped visual bullets (\* / \-) to real list items
- Plain text: keep > unchanged so quoted lines become Markdown
  blockquotes; classify blockquote lines as isBlock so they neither
  receive nor trigger hard-break markers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove any <img> whose src is not a cid: URL — eliminates logos,
layout images, and tracking pixels. Inline attachments (cid:) are
preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add U+034F (combining grapheme joiner) to invisible-char strip in
  preprocessHtml — used by newsletters to pad email preview text
- Remove Markdown links with empty text after conversion, which appear
  when stripped images were wrapped in <a> tags (tracking/logo anchors)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- from/to use "Name <email>" strings instead of nested name/email objects
- attachments use "filename (N bytes)" strings instead of nested objects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Newsletters often use <th> for multi-column image/text grids. A <th>
containing block-level elements (img, div, p, table, headings, etc.)
is a layout cell, not a data header — unwrap to prose instead of
forcing a broken GFM pipe table.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tab-only lines used as separators were not classified as empty, causing
the preceding content line to receive a spurious hard-break marker \.
Also trims trailing whitespace from all non-empty plain text lines.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add formats list to frontmatter (plaintext, html, or both)
- Swap body order: plain text first (canonical), HTML conversion second
- Replace triple-divider with labelled separator block so AI agents and
  humans can identify the boundary and understand the HTML section is
  automatically converted
- Rename receivedDate frontmatter field to received

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…order

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@janhapke janhapke merged commit 9ad1c02 into main Jun 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant